iT邦幫忙

2024 iThome 鐵人賽

DAY 10
0

在軟體開發中,錯誤就像是在吃魚的時候碰到魚刺:你知道這種情況遲早會發生,但總希望自己不會碰到。

每個程式語言都有一套自己的防錯工具,而 Rust 的方法非常有趣:它透過 ResultOption 這兩個枚舉來讓開發者去思考該如何面對這個問題,不但幫助你應對各種錯誤狀況,還能讓程式在運行時更安全。

上篇我們有介紹了枚舉,也有提到了關於OptionResult,熟悉如何在 Rust 開發過程善這兩個工具來提升程式開發時的自我掌握度和可讀性是很重要的。所以本篇希望透過其他例子來加深這兩個工具應用的印象。


Option:找不到的東西也不會讓程式崩潰

Option 在 Rust 中的角色很簡單:它就像一個小盒子,有時候裡面有東西(Some),有時候是空的(None)。這個機制讓我們不用擔心會不小心處理到空值而導致程式出錯。

實際場景:尋找某個值是否存在

想像你正在開發一個功能,要在一個清單中尋找特定的元素,這裡我們可以用 Option 來處理「找得到」和「找不到」的兩種情況,這樣做的好處是讓程式可以針對每種狀況做出適當的反應,而不是直接崩潰。

// 定義一個函數 find_element,接收一個整數陣列 arr 和一個目標整數 target,並指定回傳 Option<usize>
// Option<usize> 表示回傳的結果可能是某個索引值(Some),也可能是沒有找到(None)

fn find_element(arr: &[i32], target: i32) -> Option<usize> {
    // 使用迴圈遍歷 arr 切片中的每個元素及其索引
    for (index, &value) in arr.iter().enumerate() {
        // 如果當前元素與目標相符
        if value == target {
            return Some(index); // 找到目標,回傳索引
        }
    }
    None // 沒有找到,回傳 None
}

fn main() {
    // 定義一個整數陣列 numbers
    let numbers = [10, 20, 30, 40, 50];
    // 設定目標值為 30
    let target = 30;

    // 使用 match 語句來匹配 find_element 的回傳結果
    match find_element(&numbers, target) {
        // 如果找到,印出目標和索引
        Some(index) => println!("找到 {} 在索引位置 {}", target, index),
        // 如果找不到,印出找不到的訊息
        None => println!("找不到目標 {}", target),
    }
}

這段程式如何運作?

  1. find_element 函數回傳 Option<usize>,如果找到了目標值,就回傳 Some(index),表示目標的位置;如果找不到,則回傳 None
  2. main 函數中,我們使用 match 來根據返回的結果進行處理,不管是找到還是找不到,都能安全地處理。

為什麼這樣做更好?

如果不使用 Option,你可能會寫出讓程式報錯的程式碼,如直接嘗試對可能不存在的索引進行操作。Option 讓你在程式中清楚地處理這些有可能發生的「空值」情況,避免了不必要的崩潰。


Result:操作可能失敗?別擔心,事先處理就好

Result 是 Rust 中另一個常見的錯誤處理工具,它的運作模式類似於 Option,但更進一步地表達了「成功」或「失敗」的狀況,並附上詳細的訊息。可以想像它是一個通知包裹,打開包裹後,你會發現它不是送錯了,就是送對了,而且還會告訴你詳細的情況。

實際場景:讀取檔案的挑戰

假設你要讀取一個檔案的內容,但有可能檔案不存在、沒有權限讀取等等。這裡用 Result 來處理這種成功或失敗的情況,是再適合不過的了。

// 引入標準庫中的 File 結構和 io 模組,用於檔案操作和 I/O 處理
use std::fs::File;
use std::io::{self, Read};

// 定義一個函數 read_file_content,接收檔案路徑的字串切片,回傳 Result<String, io::Error>
// Result<String, io::Error> 表示操作可能成功並返回字串內容,或失敗並返回 I/O 錯誤
fn read_file_content(path: &str) -> Result<String, io::Error> {
    // 嘗試開啟指定路徑的檔案
    // File::open(path) 會返回 Result<File, io::Error>
    // 使用 ? 操作符,若開啟失敗,錯誤會自動傳遞給呼叫者
    let mut file = File::open(path)?;

    // 創建一個新的空字串,用於儲存檔案的內容
    let mut content = String::new();

    // 使用 read_to_string 方法將檔案內容讀入 content 字串中
    // 同樣使用 ? 操作符處理可能的 I/O 錯誤
    file.read_to_string(&mut content)?;

    // 若以上操作都成功,則返回 Ok 並包裝檔案內容的字串
    Ok(content)
}

fn main() {
    // 定義要讀取的檔案路徑
    let file_path = "example.txt";

    // 使用 match 語句來處理 read_file_content 的回傳結果
    match read_file_content(file_path) {
        // 如果成功(即回傳 Ok),則打印檔案內容
        Ok(content) => println!("文件內容:\n{}", content),

        // 如果失敗(即回傳 Err),則打印錯誤訊息
        Err(error) => println!("讀取文件失敗:{}", error),
    }
}

重點解說:

  1. 使用 Result 表達成功與失敗

    • Result<String, io::Error> 是一個泛型類別,表達這個操作的結果可以是成功,並攜帶字串內容(Ok(String)),或是失敗並攜帶錯誤訊息(Err(io::Error))。
  2. ? 操作符的作用

    • ? 操作符是 Rust 中一個非常方便的錯誤處理機制。如果函數遇到 Err或者None,它會立即返回這個錯誤給呼叫者,而不需要明確使用 return
    • 如果操作成功,? 會自動將結果帶入,讓程式繼續往下執行。
  3. 詳細的流程

    • 開啟檔案File::open(path)? 用來開啟指定的檔案路徑,若開啟失敗,則回傳為錯誤。
    • 讀取內容file.read_to_string(&mut content)? 將檔案內容讀入 content 字串中,若失敗同樣會傳遞錯誤。
    • 返回結果:若兩步驟都成功,函數會返回 Ok(content),即檔案的內容,並將content返回。
  4. match 語句處理 Result

    • 使用 match 來根據 Result 的回傳結果決定下一步動作。若為 Ok,則顯示檔案內容;若為 Err,則顯示錯誤訊息,避免程式崩潰。

為什麼這麼做很聰明?

使用 Result,你能確保每次的操作都考慮到了失敗的可能性。與其讓程式在發生問題後才崩潰,不如事先做好萬全準備,讓使用者知道為什麼操作失敗了,而且用一個 ? 就可以相當於 python 的 try except 作用,讓程式碼更簡潔。


進一步認識 unwrapexpectmapand_then

Rust 提供了幾種方便的方式來操作 ResultOption ,讓程式碼更加簡潔且易於處理錯誤。以下介紹幾個常見的用法及其詳細的操作。

unwrapexpect

我們先來看一個如何使用 unwrapexpect 的例子

use std::fs::File;
use std::io::Write;

fn main() {
    // 範例 1: 使用 `unwrap` 打開已知存在的檔案
    // 我們假設這個檔案已經存在且可讀取
    let mut file = File::create("example.txt").unwrap(); // 假設檔案建立一定成功,不會 panic
    writeln!(file, "Hello, Rust!").unwrap(); // 寫入檔案,如果操作成功,這樣可以簡潔地執行

    // 範例 2: 使用 `expect` 處理已驗證過的數字轉換
    let number_str = "42"; // 假設這個字串是經過驗證的
    let number: i32 = number_str.parse().expect("轉換為整數時出錯"); // 轉換一定成功,不會 panic
    println!("轉換成功的數字是:{}", number);

    // 範例 3: 使用 `expect` 處理已經確認不會失敗的選項
    let some_value = Some(100); // 已知這裡的值一定是 Some
    let value = some_value.expect("值應該存在但卻不見了"); // 因為確定是 Some,所以 expect 不會 panic
    println!("取出的值是:{}", value);
}

在上面的例子中,範例1的程式內由於 let mut file = File::create("example.txt") 在沒有加入 unwrap 之前,其實是會回傳 Result 的,所以理論上還需要透過 match 等判斷方式來看回傳的 ResultOk 還是 Err,才能進一步取值。

let mut file = match File::create("example.txt") {
    Ok(f) => f,
    Err(e) => {
        println!("建立檔案失敗: {}", e);
        return;
    }
};

因此如果直接使用 unwrap 就能夠在回傳的 Result<T, E>Ok 時,直接代入檔案建立成功的結果。這樣的操作在回傳值為 Option 枚舉的情況下同樣適用,這樣可以增加編寫時的便利性。

但嘗試透過 unwrapOptionResult 中取出值是有一定風險的,如果回傳的變體是 NoneErr,程式會直接 panic,也就是例外跳出崩潰。因此使用 unwrap 適合在非常確信不會出錯的情況下使用來取值,否則仍是以 match 方式分別處理較合適。

而範例2跟範例3,都使用了 expect ,那麼有用跟沒有用差別在哪裡呢?

其中如果不加上expect ,直接寫 let number: i32 = number_str.parse(); ,則這樣定義其實是會出錯的,因為 number_str.parse() 的回傳值會是 Result<T, E> ,因此同範例1,這裡的number也還需要透過 match 或者是回傳的變體判斷才能進一步取值。

然而,當我們的回傳值為 Option 或者 Result 的情況下,加上 expect 的使用也就代表著,如果不是 None 或者 Err 的話就把成功結果回傳,否則就打印出自定義的錯誤訊息。由於上面沒有發生panic,所以得到的結果如下圖:

https://ithelp.ithome.com.tw/upload/images/20240923/20121176yCCGjqormG.png

  • expect:與 unwrap 功能相似,但可以自定義錯誤訊息。在錯誤發生時,會顯示自定義的錯誤訊息,這對於除錯非常有幫助。

遇到panic會如何?

fn main() {
    // 定義一個 `Some(10)` 的 Option 變數
    let some_value = Some(10);
    // 定義一個 `None` 的 Option 變數,類別為 `Option<i32>`
    let none_value: Option<i32> = None;

    // 使用 `unwrap()` 從 `some_value` 中取出值
    println!("值:{}", some_value.unwrap()); // 輸出 10

    // 嘗試使用 `unwrap()` 從 `none_value` 中取值,若為 `None` 會直接 panic
    // println!("值:{}", none_value.unwrap()); // 若執行這行,程式會 panic,顯示錯誤訊息

    // 定義一個 `Err` 的 Result 變數
    let result: Result<i32, &str> = Err("操作失敗");
    // 使用 `expect()`,若出錯會顯示自訂錯誤訊息
    result.expect("期待成功但卻失敗了"); // 顯示自訂錯誤訊息 "期待成功但卻失敗了"
}

實際執行畫面:

https://ithelp.ithome.com.tw/upload/images/20240923/20121176S8Hy4bfIzo.png

在上面的結果中可以看到 expect 的作用,也就是會在遇到panic時打印出自定義的錯誤訊息,因此將有助於我們發現是在哪裡遇到了 None 或者 Err

unwrapexpect,如何選擇?

既然我們知道 unwrapexpect 的目的都是方便我們遇到 Option 或者 Result 的時候取值使用,而 expect 又可以更進一步自定義錯誤訊息,那為什麼我們不直接都使用 expect 就好了呢?

其實這樣理解是正確的,但是在長期的開發情況下,如果每次都要使用 expect 並且後面要加上 "" 或者其他自定義錯誤訊息,也是會影響整體開發體驗的。所以這就端看各位開發者自己的選擇囉,但個人是覺得如果可以接受 expect 後面能不加上字串設定,那就太好了。

mapand_then

  • map:用來對 SomeOk 的值進行操作,並返回一個新的 OptionResult。它可以讓你在保持原始結構的情況下,對其中的值進行處理。

  • and_then:用於串接多個操作,特別是在你需要連續處理 OptionResult 時非常實用。and_then 會接收一個返回 OptionResult 的函數,讓你能夠更方便地鏈式處理多個步驟。

fn main() {
    // 定義一個 `Some(5)` 的 Option 變數
    let some_value = Some(5);
    // 使用 `map` 對 `Some(5)` 進行乘 2 操作,返回 `Some(10)`
    let new_value = some_value.map(|x| x * 2); // 將 5 乘以 2
    // 輸出 `Some(10)`
    println!("{:?}", new_value); // 輸出 Some(10)

    // 使用 `and_then` 來連續處理 Option
    let value = Some(4).and_then(square_if_even); // 4 是偶數,返回 Some(16)
    // 輸出 `Some(16)`
    println!("{:?}", value); // 輸出 Some(16)
}

// 定義一個函數 `square_if_even`,接受一個整數並返回 Option<i32>
fn square_if_even(x: i32) -> Option<i32> {
    // 判斷是否為偶數
    if x % 2 == 0 {
        Some(x * x) // 是偶數,返回平方值
    } else {
        None // 不是偶數,返回 None
    }
}

重點解說:

  1. map 的使用場景

    • map 用於對 SomeOk 中的值進行處理,而不影響原本的結構。這讓程式碼更具表現力,尤其是在需要對多個 OptionResult 值進行不同操作時。
  2. and_then 的作用

    • and_then 可以理解為「然後做什麼」的操作。當前一個操作成功時,會繼續進行下一步,這在連續多步驟的處理中非常方便,可以大幅減少冗長的 match 語句。

Result or Option?

讀者看到這邊應該可以發現,Rust程式開發當中,我們會需要處理大量的 OptionResult 的判斷,因此,不僅僅是我們需要在我們原創的程式碼中善用兩者 ,也要能夠判斷引用的套件或其他程式碼內容當中,是返回哪一種以及如何去應對。


實際案例:把 OptionResult 結合起來用

假設你在開發一個程式,需要同時處理 I/O 錯誤和數學運算錯誤,這裡我們可以用 ResultOption 的組合來應對各種狀況。

use std::fs::File;
use std::io::{self, Read};

fn read_file_content(path: &str) -> Result<String, io::Error> {
    let mut file = File::open(path)?;
    let mut content = String::new();
    file.read_to_string(&mut content)?;
    Ok(content)
}

fn safe_divide(a: f64, b: f64) -> Option<f64> {
    if b == 0.0 {
        None
    } else {
        Some(a / b)
    }
}

fn main() {
    // 文件讀取範例
    let file_path = "example.txt";
    match read_file_content(file_path) {
        Ok(content) => println!("文件內容:\n{}", content),
        Err(error) => println!("讀取文件失敗:{}", error),
    }

    // 安全除法範例
    let a = 10.0;
    let b = 0.0;
    match safe_divide(a, b) {
        Some(result) => println!("除法結果:{}", result),
        None => println!("錯誤:分母不能為 0"),
    }
}

總結

在 Rust 中, ResultOption 是兩個Rust開發中常見的枚舉,熟悉應對與巧秒運用,則將能夠使自己更能夠掌握程式的運作過程中對不同情況發生的反應,並且依據可能的情況進行排除。

對我自己而言,雖然Python的 try except可以很簡單的略過錯誤情況而使程式不至於遇到panic終止,但是由於它的特性,造成 Silent Errors 的情況,反而隱藏重要的錯誤原因,這情況也是時常可能發生的。

而Rust的 ? 就等同於是簡化版的 try except,適合在一個段落裡多次呼叫,又不佔空間也很直觀,這是我認為最好用的部分之一。

以上就是一些關於 ResultOption 的相關操作,不知道有沒有人跟我一樣,到後來才發現 expect != except,太習慣python的錯誤處理方式了。


上一篇
[Day 9] Rust 中的枚舉:代碼範例與應用
下一篇
[Day 11] Rust 的模組與套件管理:如何引用模組
系列文
從 Python 開發者的角度學習 Rust —— 從語法基礎到實戰應用30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言